spb/tools-spboucher-ai
Public
1import type { Metadata } from "next";2import Link from "next/link";3import { notFound } from "next/navigation";4import { getTool, relatedTools, allPublicSlugs } from "@/lib/public/queries";5import { renderMarkdown } from "@/lib/text";6import { label, fmtDate } from "@/lib/utils";7import { Badge } from "@/components/ui/badge";8import { LogoMark } from "@/components/ui/logo-mark";9import { Breadcrumbs } from "@/components/breadcrumbs";10import { ToolCard } from "@/components/tool-card";11import { ReportForm } from "@/components/report-form";12import { ExternalLink, BookOpen, GitBranch, Receipt, Scale, ShieldCheck, Lock, AlertTriangle, Check, Minus, Scale3D } from "lucide-react";13import { APP_URL } from "@/lib/env";1415export const revalidate = 600;16export async function generateStaticParams() { try { return (await allPublicSlugs()).slice(0, 500).map((r) => ({ slug: r.slug })); } catch { return []; } }17export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {18 const t = await getTool((await params).slug); if (!t) return { title: "Not found" };19 return { title: `${t.name} — ${t.summary.slice(0, 60)}`, description: t.summary, alternates: { canonical: `/tools/${t.slug}` }, openGraph: { title: t.name, description: t.summary, type: "article", url: `${APP_URL}/tools/${t.slug}` } };20}2122function Section({ id, title, children }: { id: string; title: string; children: React.ReactNode }) {23 return <section id={id} aria-labelledby={`${id}-h`} className="scroll-mt-24"><h2 id={`${id}-h`} className="display h2 mb-3">{title}</h2>{children}</section>;24}25function List({ items, icon }: { items: string[]; icon?: "check" | "minus" }) {26 if (!items.length) return <p className="text-sm text-fg-3">Not documented.</p>;27 const I = icon === "minus" ? Minus : Check;28 return <ul className="grid gap-1.5 sm:grid-cols-2">{items.map((x) => <li key={x} className="flex items-start gap-2 text-[0.95rem] text-fg-2"><I className={`mt-1 h-4 w-4 shrink-0 ${icon === "minus" ? "text-warn" : "text-ok"}`} aria-hidden />{x}</li>)}</ul>;29}30function Row({ k, v }: { k: string; v: React.ReactNode }) { return <div className="border-t border-line py-2.5 first:border-t-0 first:pt-0"><dt className="mb-1 text-[11px] font-semibold uppercase tracking-wider text-fg-3">{k}</dt><dd className="flex flex-wrap gap-1.5 text-sm text-fg-2">{v}</dd></div>; }31const chips = (xs: string[]) => (xs.length ? xs.map((x) => <Badge key={x}>{label(x)}</Badge>) : <span className="text-fg-3">—</span>);3233export default async function ToolPage({ params }: { params: Promise<{ slug: string }> }) {34 const { slug } = await params;35 const t = await getTool(slug); if (!t) notFound();36 const related = await relatedTools(t.id);37 const primary = t.categories.find((c) => c.isPrimary) ?? t.categories[0];38 const html = renderMarkdown(t.description);39 const links = [40 { href: t.homepageUrl, label: "Official site", icon: ExternalLink, field: "homepage_url" }, { href: t.docsUrl, label: "Documentation", icon: BookOpen, field: "docs_url" }, { href: t.repoUrl, label: "Repository", icon: GitBranch, field: "repo_url" },41 { href: t.pricingUrl, label: "Pricing", icon: Receipt, field: "pricing_url" }, { href: t.licenseUrl, label: "License", icon: Scale, field: "license_url" }, { href: t.privacyUrl, label: "Privacy policy", icon: Lock, field: "privacy_url" }, { href: t.securityUrl, label: "Security", icon: ShieldCheck, field: "security_url" },42 ].filter((l) => l.href);43 const lc = new Map(t.linkChecks.map((l) => [l.field, l]));44 const jsonLd = {45 "@context": "https://schema.org", "@type": "SoftwareApplication", name: t.name, url: t.homepageUrl, description: t.summary,46 ...(t.operatingSystems.length ? { operatingSystem: t.operatingSystems.map(label).join(", ") } : {}), ...(primary ? { applicationCategory: primary.name } : {}),47 ...(t.vendor ? { author: { "@type": "Organization", name: t.vendor.name, ...(t.vendor.websiteUrl ? { url: t.vendor.websiteUrl } : {}) } } : {}),48 ...(t.pricingModel.includes("free") || t.pricingModel.includes("open-source") ? { offers: { "@type": "Offer", price: "0", priceCurrency: "USD" } } : {}),49 ...(t.licenseSpdx && t.licenseUrl ? { license: t.licenseUrl } : {}),50 };51 const breadcrumbLd = { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [{ "@type": "ListItem", position: 1, name: "Home", item: APP_URL }, ...(primary?.parent ? [{ "@type": "ListItem", position: 2, name: primary.parent.name, item: `${APP_URL}/categories/${primary.parent.slug}` }] : []), ...(primary ? [{ "@type": "ListItem", position: 3, name: primary.name, item: `${APP_URL}/categories/${primary.slug}` }] : []), { "@type": "ListItem", position: 4, name: t.name, item: `${APP_URL}/tools/${t.slug}` }] };5253 return (54 <article className="mx-auto max-w-7xl px-4 py-8 sm:px-6">55 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }} />56 <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbLd).replace(/</g, "\\u003c") }} />57 <Breadcrumbs items={[...(primary?.parent ? [{ href: `/categories/${primary.parent.slug}`, label: primary.parent.name }] : []), ...(primary ? [{ href: `/categories/${primary.slug}`, label: primary.name }] : []), { label: t.name }]} />5859 <header className="mt-4 flex flex-col gap-5 md:flex-row md:items-start">60 <LogoMark name={t.name} size={72} className="rounded-2xl" />61 <div className="min-w-0 flex-1">62 <div className="flex flex-wrap items-center gap-2">63 <h1 className="display h1">{t.name}</h1>64 {t.depth === "deep" && <Badge tone="accent">In-depth entry</Badge>}65 {t.productStatus !== "active" && <Badge tone={t.productStatus === "unknown" ? "warn" : "bad"}>{label(t.productStatus)}</Badge>}66 </div>67 <p className="mt-1 text-sm text-fg-3">{t.vendor ? <>by {t.vendor.websiteUrl ? <a href={t.vendor.websiteUrl} rel="noopener noreferrer nofollow" target="_blank" className="hover:text-fg">{t.vendor.name}</a> : t.vendor.name} · </> : null}{label(t.productType)}{t.aliases.length ? <> · also known as {t.aliases.join(", ")}</> : null}</p>68 <p className="mt-3 max-w-3xl text-lg leading-snug text-fg-2">{t.summary}</p>69 <div className="mt-4 flex flex-wrap gap-1.5">70 <Badge tone={t.sourceModel === "open-source" ? "ok" : "neutral"} title="Source model / license">{t.licenseSpdx ? `${t.licenseSpdx} license` : label(t.sourceModel)}</Badge>71 {t.pricingModel.map((p) => <Badge key={p} title="Pricing model (no prices are recorded)">{label(p)}</Badge>)}72 {t.executionModes.map((e) => <Badge key={e} tone="info">{label(e)}</Badge>)}73 {t.selfHostable && <Badge tone="info">Self-hostable</Badge>}74 </div>75 </div>76 <div className="flex shrink-0 flex-col gap-2 md:w-60">77 <a href={t.homepageUrl} rel="noopener noreferrer" target="_blank" className="btn-primary">Visit official site <ExternalLink className="h-4 w-4" aria-hidden /></a>78 <Link href={`/compare?tools=${t.slug}`} className="btn-ghost" rel="nofollow" aria-label={`Compare ${t.name} with other tools`}><Scale3D className="h-4 w-4" aria-hidden /> Compare</Link>79 <p className="text-center text-[11px] text-fg-3">{lc.get("homepage_url")?.ok ? <>Link verified {fmtDate(lc.get("homepage_url")!.checkedAt)}</> : t.checkedAt ? <>Checked {fmtDate(t.checkedAt)}</> : "Link check pending"}</p>80 </div>81 </header>8283 <div className="mt-10 grid gap-10 lg:grid-cols-[1fr_20rem]">84 <div className="space-y-12">85 <Section id="overview" title="Overview">86 <div className="prose-tool" dangerouslySetInnerHTML={{ __html: html }} />87 {t.uncertaintyNote && <p className="mt-4 flex items-start gap-2 rounded-xl border border-warn/30 bg-warn/10 p-3 text-sm text-fg-2"><AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warn" aria-hidden /><span><strong className="text-fg">Editorial note on uncertainty.</strong> {t.uncertaintyNote}</span></p>}88 </Section>89 <Section id="capabilities" title="Capabilities"><List items={t.capabilities} /></Section>90 {t.workflows.length > 0 && <Section id="workflows" title="Typical workflows"><ol className="list-decimal space-y-1.5 pl-5 text-[0.95rem] text-fg-2">{t.workflows.map((w) => <li key={w}>{w}</li>)}</ol></Section>}91 <div className="grid gap-8 md:grid-cols-2">92 <Section id="advantages" title="Strengths"><List items={t.advantages} /></Section>93 <Section id="limitations" title="Limitations"><List items={t.limitations} icon="minus" /></Section>94 </div>95 {(t.prerequisites.length > 0 || t.audiences.length > 0) && (96 <div className="grid gap-8 md:grid-cols-2">97 {t.prerequisites.length > 0 && <Section id="prerequisites" title="Prerequisites"><List items={t.prerequisites} /></Section>}98 {t.audiences.length > 0 && <Section id="audiences" title="Who it is for"><List items={t.audiences} /></Section>}99 </div>100 )}101 {(t.alternatives.length > 0 || t.comparisonNotes) && (102 <Section id="alternatives" title="Alternatives & comparison">103 {t.comparisonNotes && <p className="prose-tool mb-4">{t.comparisonNotes}</p>}104 {t.alternatives.length > 0 && (105 <ul className="grid gap-2 sm:grid-cols-2">{t.alternatives.map((a) => (106 <li key={a.slug} className="card p-3"><Link href={`/tools/${a.slug}`} className="font-medium hover:text-accent">{a.name}</Link><p className="mt-0.5 line-clamp-2 text-xs text-fg-3">{a.summary}</p><div className="mt-1.5 flex gap-1"><Badge>{label(a.sourceModel)}</Badge>{a.pricingModel.slice(0, 1).map((p) => <Badge key={p}>{label(p)}</Badge>)}</div></li>107 ))}</ul>108 )}109 {t.alternatives.length > 1 && <Link href={`/compare?tools=${[t.slug, ...t.alternatives.slice(0, 3).map((a) => a.slug)].join(",")}`} rel="nofollow" className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline">Compare {t.name} with its alternatives →</Link>}110 </Section>111 )}112 <Section id="sources" title="Sources & verification">113 <p className="mb-3 text-sm text-fg-2">Primary sources consulted for this record. Claims are tied to the fields each source supports; the automated link checker re-verifies every URL on a schedule.</p>114 <ul className="divide-y divide-line rounded-2xl border border-line bg-bg-2">115 {t.sources.map((s) => (116 <li key={s.url} className="flex flex-col gap-1 p-3 sm:flex-row sm:items-start sm:gap-3">117 <Badge className="shrink-0">{label(s.sourceType)}</Badge>118 <div className="min-w-0 flex-1"><a href={s.url} rel="noopener noreferrer nofollow" target="_blank" className="break-all text-sm text-accent hover:underline">{s.title ?? s.url}</a>{s.title && <p className="truncate text-xs text-fg-3">{s.url}</p>}{s.supports.length > 0 && <p className="mt-1 text-xs text-fg-3">Supports: {s.supports.join(", ")}</p>}{s.note && <p className="mt-1 text-xs text-fg-2">{s.note}</p>}</div>119 <span className="shrink-0 text-xs text-fg-3">checked {fmtDate(s.checkedAt)}</span>120 </li>121 ))}122 </ul>123 <dl className="mt-4 grid gap-2 text-sm sm:grid-cols-3">124 <div className="rounded-xl bg-bg-3/60 p-3"><dt className="eyebrow">Confidence</dt><dd className="mt-1 font-medium">{label(t.confidence)}</dd></div>125 <div className="rounded-xl bg-bg-3/60 p-3"><dt className="eyebrow">Reviewed</dt><dd className="mt-1 font-medium">{fmtDate(t.reviewedAt ?? t.publishedAt)}</dd></div>126 <div className="rounded-xl bg-bg-3/60 p-3"><dt className="eyebrow">Links</dt><dd className="mt-1 font-medium">{label(t.linkState)}{t.checkedAt ? ` · ${fmtDate(t.checkedAt)}` : ""}</dd></div>127 </dl>128 </Section>129 <Section id="report" title="Spotted an error?"><ReportForm slug={t.slug} /></Section>130 </div>131132 <aside className="space-y-6 lg:sticky lg:top-20 lg:self-start" aria-label="Facts and links">133 <div className="card p-4">134 <h2 className="eyebrow mb-2">Official links</h2>135 <ul className="space-y-1">{links.map((l) => { const c = lc.get(l.field); return (136 <li key={l.field}><a href={l.href!} rel="noopener noreferrer nofollow" target="_blank" className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-fg-2 hover:bg-bg-3 hover:text-fg"><l.icon className="h-4 w-4 shrink-0 text-fg-3" aria-hidden />{l.label}<span className="ml-auto" title={c ? (c.ok ? `OK ${c.statusCode ?? ""}` : `Problem ${c.statusCode ?? ""}`) : "not yet checked"}>{c ? <span className={`block h-1.5 w-1.5 rounded-full ${c.ok ? "bg-ok" : "bg-bad"}`} aria-label={c.ok ? "link verified" : "link problem"} /> : null}</span></a></li>137 ); })}</ul>138 </div>139 <div className="card p-4">140 <h2 className="eyebrow mb-1">Facts</h2>141 <dl>142 <Row k="Categories" v={t.categories.map((c) => <Link key={c.slug} href={`/categories/${c.slug}`} className="chip hover:border-accent/40 hover:text-accent" title={c.label ?? undefined}>{c.name}</Link>)} />143 <Row k="Runs" v={chips(t.executionModes)} />144 <Row k="Interfaces" v={chips(t.interfaces)} />145 <Row k="Operating systems" v={chips(t.operatingSystems)} />146 {t.deploymentModels.length > 0 && <Row k="Deployment" v={chips(t.deploymentModels)} />}147 <Row k="Source model" v={<span>{label(t.sourceModel)}{t.licenseSpdx ? ` · ${t.licenseSpdx}` : ""}</span>} />148 <Row k="Pricing model" v={<span>{t.pricingModel.map(label).join(", ")}{t.pricingCheckedAt ? <span className="block text-xs text-fg-3">checked {fmtDate(t.pricingCheckedAt)}</span> : null}</span>} />149 <Row k="Self-hostable" v={t.selfHostable === null ? "Unknown" : t.selfHostable ? "Yes" : "No"} />150 {t.languages.length > 0 && <Row k="Languages" v={chips(t.languages)} />}151 {t.dataFormats.length > 0 && <Row k="Data formats" v={chips(t.dataFormats)} />}152 {t.integrations.length > 0 && <Row k="Integrations" v={chips(t.integrations)} />}153 {t.privacyPosture && <Row k="Privacy posture" v={<span>{t.privacyPosture}</span>} />}154 {t.dataResidency && <Row k="Data residency" v={<span>{t.dataResidency}</span>} />}155 </dl>156 </div>157 {(t.tags.length > 0 || t.useCases.length > 0) && (158 <div className="card p-4">159 <h2 className="eyebrow mb-2">Tags & use cases</h2>160 <div className="flex flex-wrap gap-1.5">{t.useCases.map((u) => <Link key={u.slug} href={`/tools?use=${u.slug}`} rel="nofollow" className="chip chip-accent">{u.name}</Link>)}{t.tags.map((g) => <Link key={g.slug} href={`/tools?tag=${g.slug}`} rel="nofollow" className="chip">#{g.slug}</Link>)}</div>161 </div>162 )}163 {t.collections.length > 0 && <div className="card p-4"><h2 className="eyebrow mb-2">In collections</h2><ul className="space-y-1 text-sm">{t.collections.map((c) => <li key={c.slug}><Link href={`/collections/${c.slug}`} className="text-accent hover:underline">{c.title}</Link></li>)}</ul></div>}164 </aside>165 </div>166167 {related.length > 0 && (168 <section className="mt-16" aria-labelledby="related-h">169 <h2 id="related-h" className="display h2 mb-4">More in {primary?.name ?? "this category"}</h2>170 <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">{related.map((r) => <ToolCard key={r.id} t={r} />)}</div>171 </section>172 )}173 </article>174 );175}176